/*
 * Created on 29 Mar 2007 by Andrew White
 */

package com.volatileshooter.networking.io;

import java.io.IOException;
import java.nio.BufferOverflowException;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Queue;

import com.volatileshooter.networking.io.packets.AcceptPacket;
import com.volatileshooter.networking.io.packets.ChatPacket;
import com.volatileshooter.networking.io.packets.JoinPacket;
import com.volatileshooter.networking.io.packets.QuitPacket;
import com.volatileshooter.networking.io.packets.RejectPacket;
import com.volatileshooter.networking.server.util.Log;




/*
 * Handles sending and receiving packets via a provided UDP DatagramChannel. 
 * Different types of game packets are defined here, and a dedicated thread
 * deals with ensuring 'Heavy' packets are received successfully. A 'Heavy'
 * packet will be re-sent x number of times if no response is received within
 * the specified time out before giving up.
 */
public class GameChannel implements Runnable {

    /* events */
    protected static final byte RESPONSE_EVENT = 0;
    public static final byte JOIN_EVENT = 1;
    public static final byte ACCEPT_EVENT = 2;
    public static final byte REJECT_EVENT = 3;
    public static final byte QUIT_EVENT = 4;
    public static final byte RELATIVE_UPDATE_EVENT = 5;
    public static final byte ABSOLUTE_UPDATE_EVENT = 6;
    public static final byte PLAYER_JOINED_EVENT = 7;
    public static final byte PLAYER_LEFT_EVENT = 8;
    public static final byte PLAYER_KILLED_EVENT = 9;
    public static final byte CHAT_EVENT = 10;
    public static final byte MAPCHANGE_EVENT = 11;
    public static final byte MAP_REQUEST_EVENT = 12;
    public static final byte MAP_DATA_EVENT = 13;
    public static final byte PING_EVENT = 13;
    
    /* time (milliseconds) to wait for response of important packets before re-sending them */
    private final long RESPONSE_TIMEOUT = 500;

    /* number of attempts to make at sending an important packet */
    private final int MAX_ATTEMPTS = 10;

    /* channel to write packets to */
    private DatagramChannel channel;

    /* buffer to write packets to channel */
    private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(255);

    /* buffer to read packets to channel */
    private ByteBuffer readBuffer = ByteBuffer.allocateDirect(255);

    /* queue of heavy packets to keep sending until a reply is received */
    private Queue<GamePacket> heavyPacketQueue = new LinkedList<GamePacket>();

    /**
     * Constructs a new WriteBuffer on the channel provided.
     * 
     * @param channel
     *            DatagramChannel to write packets to
     */
    public GameChannel(DatagramChannel channel) {
        // set channel
        this.channel = channel;

        // start new thread with a low priority since this is a background task
        Thread thread = new Thread(this, "GameChannel");
        thread.setPriority(Thread.MIN_PRIORITY);
        thread.start();
    }

    /**
     * Calls update in a continuos loop
     */
    public void run() {
        while (true) {
            // update
            update();
            // sleep for RESPONSE_TIMEOUT since update doesn't need to be called
            // more frequently than this
            try {
                Thread.sleep(RESPONSE_TIMEOUT);
            } catch (InterruptedException e) {
                Log.warning("", e);
            }
        }
    }

    /**
     * Re-sends the head packet of the heavyPacketQueue if no response has been
     * received within the RESPONSE_TIMEOUT, if however more than the
     * MAX_ATTEMPTS have been made to send the packet then this packet is
     * removed from the queue.
     */
    public void update() {
        synchronized (heavyPacketQueue) {
            // get head packet from queue (leave it there)
            GamePacket p = heavyPacketQueue.peek();
            if (p != null) {

                // check if no response received within RESPONSE_TIMEOUT
                if (p.timestamp < System.currentTimeMillis() - RESPONSE_TIMEOUT) {
                    // re-send packet
                    sendPacket(p);
                   
                    // check if MAX_ATTEMPTS to send packet made
                    if (p.attempts >= MAX_ATTEMPTS) {
                        // pop head packet from heavyPacketQueue
                        heavyPacketQueue.remove();
                        
                        String to = "server";
                        if (p.addr != null) to = p.addr.toString();
                        
                        Log.warning("Failed to send heavy packet (" +
                                p.event + ") to " + to +
                                " after " + MAX_ATTEMPTS + " attempts",
                                null);
                    }
                }
            }
        }
    }

    /**
     * Reads in packet from channel. If this fails, the packet is corrupt or
     * incomplete then null is returned.
     * 
     * @return Packet read in from channel
     */
    public GamePacket readPacket() {
        // temporary packet to store properties as we read them in
        GamePacket temp = new GamePacket((short)0, null);
        try {
            // read bytes to buffer from channel
            readBuffer.clear();
            if (channel.isConnected()) {
                temp.addr = channel.socket().getRemoteSocketAddress();
                channel.read(readBuffer);
            } else {
                temp.addr = channel.receive(readBuffer);
            }
            readBuffer.flip();

            // get packet header
            temp.clientId = readBuffer.getShort();
            temp.packetId = readBuffer.getInt();
            temp.event = readBuffer.get();
            short payload_len = readBuffer.getShort();
            
            // use packet event type to create correct packet object
            GamePacket packet;
            switch(temp.event) {
                case GameChannel.JOIN_EVENT:
                    packet = new JoinPacket(temp.clientId, temp.addr, null);
                    break;
                case GameChannel.ACCEPT_EVENT:
                    packet = new AcceptPacket(temp.clientId, temp.addr, null);
                    break;
                case GameChannel.REJECT_EVENT:
                    packet = new RejectPacket(temp.clientId, temp.addr, null);
                    break;
                case GameChannel.QUIT_EVENT:
                    packet = new QuitPacket(temp.clientId, temp.addr);
                    break;
                case GameChannel.CHAT_EVENT:
                    packet = new ChatPacket(temp.clientId, temp.addr, null);
                    break;
                case GameChannel.RESPONSE_EVENT:
                    packet = new ResponsePacket(temp);
                    break;
                default:
                    Log.warning("Ignoring packet from " + temp.clientId +
                            " [" + temp.addr + "], unknown event type " + 
                            temp.event, null);
                    return null;
            }
            // put header from temp into packet
            packet.packetId = temp.packetId;
            packet.event = temp.event;
            
            // check correct number of bytes left to read in payload
            if (readBuffer.remaining() != payload_len) {
                throw new BufferUnderflowException();
            }

            // get payload
            packet.payload = new byte[payload_len];
            readBuffer.get(packet.payload, 0, packet.payload.length);

            // cleanup read buffer
            readBuffer.clear();
                        
            // check if packet is heavy weight and needs response
            if (packet.weight == GamePacket.HEAVY) {
                // create empty response packet
                ResponsePacket packet_response = new ResponsePacket(packet);
                packet_response.event = RESPONSE_EVENT;
                // send response packet
                sendPacket(packet_response);
            } else {
                // check if packet is a response to a heavy weight packet
                if (packet.event == RESPONSE_EVENT) {
                    synchronized (heavyPacketQueue) {
                        // loop through heavy packet queue to find
                        // packet to remove by comparing the packet id from
                        // the response packet
                        Iterator<GamePacket> it = heavyPacketQueue.iterator();
                        while (it.hasNext()) {
                            GamePacket p = it.next();
                            if (p.packetId == packet.packetId) {
                                heavyPacketQueue.remove(p);
                                break;
                            }
                        }
                    }
                    // no need to return this packet as it is a response
                    // packet dealt with here
                    return null;
                }
            }
            
            // return packet
            return packet;
            
        } catch (IOException e) {
            Log.warning("Failed to read packet", e);
        } catch (BufferUnderflowException e) {
            Log.warning("Packet from [" + temp.addr + "] corrupt", e);
        }
        
        return null;
    }

    /**
     * Sends a packet via the channel.
     * 
     * @param packet
     *            Packet to send
     */
    public synchronized void sendPacket(GamePacket packet) {
        
        // check packet is not empty
        if (packet == null) {
            Log.warning("Attempted to send empty packet", null);
            return;
        }
        if (packet.payload == null) {
            Log.warning("Attempted to send packet with empty payload", null);
            return;
        }

        // send packet
        try {
            // prepares the buffer for writing to the channel
            writeBuffer.clear();

            // write packet header to buffer
            writeBuffer.putShort(packet.clientId);
            writeBuffer.putInt(packet.packetId);
            writeBuffer.put(packet.event);
            writeBuffer.putShort((short) packet.payload.length);

            // write packet payload to buffer
            writeBuffer.put(packet.payload);

            writeBuffer.flip();

            // check if this is a heavy weight packet
            if (packet.weight == GamePacket.HEAVY) {
                // put time stamp on packet
                packet.timestamp = System.currentTimeMillis();
                // add heavy weight packet to heavyPacketQueue if it has not already
                // been added
                if (packet.attempts == 0) {
                    synchronized (heavyPacketQueue) {
                        heavyPacketQueue.add(packet);
                    }
                }
                // increase number of attempts made to send packet
                packet.attempts++;
            }
            
            // send the packet via the channel
            long nbytes = 0;
            long toWrite = writeBuffer.remaining();
            // loop to make sure all bytes are written
            while (nbytes != toWrite) {
                if (channel.isConnected()) {
                    nbytes += channel.write(writeBuffer);
                } else {
                    if (packet.addr == null) {
                        throw new IOException("No packet address specified for connectionless channel");
                    }
                    
                    nbytes += channel.send(writeBuffer, packet.addr);
                }
                Thread.yield();
            }

            // cleanup write buffer
            writeBuffer.rewind();
        } catch (IOException e) {
            Log.warning("Failed to send packet", e);
        } catch (BufferOverflowException e) {
            Log.warning("Failed to send packet, buffer overflow", e);
        }
    }

}
